iT邦幫忙

2023 iThome 鐵人賽

DAY 29
0
Mobile Development

Flutter 從零到實戰 - 30 天の學習筆記系列 第 29

[Day 29] 進階技巧 - 做一個 Home Widget (實作篇)

  • 分享至 

  • xImage
  •  

今天我們要來實作 iOS版本的 home widget

昨天我們已經有說過,flutter 並無法直接撰寫 home widget 的樣式,若要在iOS 上實現必須使用 Swift 才能完成,好險本人還會一點點所以還寫得出來XDD

讓我們複習一下流程,我們需要在應用程式中主動將欲顯示的資訊存至 UserDefaults 中,再由 widget 前去提取達成更新的作用。

新增一組 App Groups

不過將資料存入、提取的過程中需要認定一組 app group 的鍵值,請打開 xcode 來設定。請點擊 Runner 中左上角的 +Capability 按鈕,並加入 App Groups 。加入後請點擊 App Groups 下方的 + 來加入新的群組。
https://ithelp.ithome.com.tw/upload/images/20231014/20135082h4cR86EmYB.png
群組名稱請取一個易辨認的名稱,同樣也將 Target 切換至 Widget Extensio 加入 App Groups 並勾選同一個項目。

從 Flutter更新內容至 UserDefaults

首先請先思考主頁的 widget 想要顯示的內容,這個專案我希望可以用於顯示數篇主頁的「焦點新聞」,因此更新 UserDefaults 的行為就會在主頁的時候觸發。請開啟 HomeScreen 的頁面,我們需要做以下幾個設定,請開啟 home_screen.dart

// xcode 設定的 group name
const String appGroupId = 'group.microNewsTutorial';
// xcode 設定的 widget name
const String iOSWidgetName = 'MicroNewsWidget';
// android studio 設定的 widget name
const String androidWidgetName = 'MicroNewsWidget';

class _HomeScreenState extends State<HomeScreen> {
  // 省略 state
  @override initState() {
    super.initState();
    // 綁定 app group id
    HomeWidget.setAppGroupId(appGroupId);
  }
}

因為我們想要更新數篇焦點新聞,但 widget 的空間有限,因此我們可以篩出新聞標題、新聞來源、新聞圖片即可。

void updateHomeWidget(List<NewsPost> posts) {
  // 抽取我們想要的欄位內容 title, cover, source
  final List<Map<String, String>> postsData = posts
      .map((post) => {
            'title': post.title,
            'cover': post.cover,
            'source': post.source.name,
          })
      .toList();
  // 以 posts 當作 key 儲存至 ios 與 anroid 本機裝置
  HomeWidget.saveWidgetData('posts', postsData);
  HomeWidget.updateWidget(
    iOSName: iOSWidgetName,
    androidName: androidWidgetName,
  );
}

這麼一來就大功告成拉!

使用 Swift 從UserDefaults 抽取內容

接下來就是重點拉!請用 xcode 或是 vscode 開啟 ios/MicroNewsWidget/MicroNewsWidget.swift 檔案,讓我們先了解一下 ios widget extension 程式架構。

TimelineEntry

TimelineEntry 是定義於 Apple 推出的 WidgetKit 中的 protocal,其會根據 date 來決定何時進行更新。

// 定義單篇新聞的資料格式
struct NewsArticle {
    let title: String
    let source: String
    let cover: String
}

struct NewsArticleEntry: TimelineEntry {
    let date: Date
    let showPlaceholder: Bool
    // stories 表示要顯示的所有新聞
    let stories: [NewsArticle]
}

Widget 與 View

表示欲顯示的小工具內容,這裡提供 View 的範例,Widget 則使用原始預設的即可。

// 表示實際要顯示的 widget 內容
struct MicroNewsWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        // 未有新聞資訊時,顯示 placeholder
        if (entry.showPlaceholder) {
            VStack(alignment: .leading) {
                Text("焦點新聞").font(.system(size: 14)).foregroundColor(.pink)
                Spacer()
                Text(entry.stories[0].source).font(.system(size: 14)).foregroundStyle(.gray)
                Text(entry.stories[0].title).font(.system(size: 16))
            }.redacted(reason: .placeholder)
        } else {
            VStack(alignment: .leading) {
                Text("焦點新聞").font(.system(size: 14)).foregroundColor(.pink)
                Spacer()
                Text(entry.stories[0].source).font(.system(size: 14)).foregroundStyle(.gray)
                Text(entry.stories[0].title).font(.system(size: 16))
            }
        }
    }
}

struct MicroNewsWidget: Widget {
    let kind: String = "MicroNewsWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MicroNewsWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                MicroNewsWidgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

TimelineProvider

TimelineProvider 是完成顯示內容的核心,其會根據不同的 TimlineEntry 來告訴 widgetkit 何時要呈現什麼樣的工具。

首先執行 getTimeline 函式,接著進入到 getSnapshot 檢視當前時間軸需要顯示的項目,再進行判斷要顯示實際內容還是 placeholder (佔位用於暫時顯示的內容)。

因此我們就要在 getSnapshot 函式中從 UserDefaults 讀取出新聞內容,並呈現出來。

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> NewsArticleEntry {
        NewsArticleEntry(date: Date(), showPlaceholder: true, stories: Array(
            arrayLiteral: NewsArticle(title: "新聞標題", source: "新聞來源", cover: "")
        ))
    }

    func getSnapshot(in context: Context, completion: @escaping (NewsArticleEntry) -> ()) {
        let entry: NewsArticleEntry
        if context.isPreview{
            entry = placeholder(in: context)
        } else {
            // 根據 app groups id 讀取 user defaults 內容
            let userDefaults = UserDefaults(suiteName: "group.microNewsTutorial")
            // 將存入的新聞列表取出 (當初以 posts 為鍵值存入,現在就怎麼取出)
            let stories = userDefaults?.array(forKey: "posts")
            // 將 posts 轉換為 NewsArticle 型態
            let newsArticles = stories?.map { story -> NewsArticle in
                let story = story as! [String: String]
                return NewsArticle(title: story["title"]!, source: story["source"]!, cover: story["cover"]!)
            }
            entry = NewsArticleEntry(date: Date(), showPlaceholder: false, stories: newsArticles!)
        }
        completion(entry)
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        getSnapshot(in: context) { (entry) in
            let timeline = Timeline(entries: [entry], policy: .atEnd)
              completion(timeline)
        }
    }
}

執行

這時候請執行看看,不過切記 xcode 與 flutter 沒辦法同時都執行,因此擇一使用即可。
https://i.imgur.com/JCxILIz.gif
看起來很棒吧!美中不足的是受限於小 widget 空間限制,最多顯示一篇新聞已經很緊繃了... 別擔心!iOS 提供了三種樣式的 widget 可供選擇,我們可以根據不同的大小給予不同的顯示效果。
https://ithelp.ithome.com.tw/upload/images/20231014/20135082ty94aoOXiz.png
不過由於篇幅的關係,我就不在文章中分別講三種 widget 的樣式寫法,不過根據 widget 大小顯示不同的內容可以從以下部分切入:

struct MicroNewsWidgetEntryView : View {
    var entry: Provider.Entry

    @Environment(\.widgetFamily) var widgetFamily
    
    var body: some View {
        switch widgetFamily {
        case .systemSmall:
            // 小 widget
            MicroNewsSmallWidget(entry: entry)
        case .systemMedium:
            // 中 widget
            MicroNewsMediumWidget(entry: entry)
        case .systemLarge:
            // 大 widget
            MicroNewsLargeWidget(entry: entry)
        default:
            Text("Unsupported widget family")
        }
    }
}

詳細的 code 可以在這裡找到,各位可以自行參考。以下顯示成果,很棒對吧!:
https://ithelp.ithome.com.tw/upload/images/20231014/20135082oQDRsz8Q5x.png

今日總結

我們今天完成了 iOS 版本的 home widget 實作,雖然今天的內容大多是在寫 swift ,不過至少讓我們稍微了解 swift 的語法以及運作模式~ 如果你是程式老手說不定看一下就自己可以魔改了XD 至於 Android 版本的實作,礙於研究時間與篇幅有限QQ 就只能等之後再回來更新了。

今天的內容真的相當困難,因為看起來是在寫 flutter,但卻要同時跨足原生語言才有辦法完整實現 home widget 的實作。然後中間哪個環節卡住了也很難除錯... 不過 home widget 對於使用者在應用程式上的體驗絕對是大大加分的!所以希望今天的內容可以對大家有所幫助😊
今天的參考程式碼:https://github.com/ChungHanLin/micro_news_tutorial/tree/day29/micro_news_app


上一篇
[Day 28] 進階技巧 - 做一個 Home Widget (設定篇)
下一篇
[Day 30] 換上 App Icon & 總結
系列文
Flutter 從零到實戰 - 30 天の學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言